一種常見的誤解是認為函式不應該填寫在 dependencies 中。我們來看一下這個範例:
function SearchResults() {
const [query, setQuery] = useState('react');
async function fetchData() {
const result = await axios(
`https://foo.com/api/search?query=${query}`,
);
// ...
}
useEffect(
() => {
fetchData();
},
[] // deps 不誠實,effect 使用了內部沒有的變數「fetchData」
);
// ...
}
在以上的範例中,如果沒有一開始就誠實地把 fetchData
填到 effect 的 dependencies 的話,當我們在 fetchData
函式中加入其它的資料依賴時,你有可能就會因為忘記補上 dependencies 而遇到 bug 卻沒有事前就發現。
此時你的第一直覺反應可能是,那我就把這個 fetchData
誠實的寫進 dependencies,應該就可以解決了?
function SearchResults() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('react');
async function fetchData() {
const result = await axios(
`https://foo.com/api/search?query=${query}`,
);
setData(result.data);
}
useEffect(
() => {
fetchData();
},
[fetchData] // 這個 deps 的效能最佳會永遠都會失敗,因為 fetchData 每次 render 時都不一樣
);
// ...
}
但其實這個範例中的 effect dependencies 效能最佳化永遠都會失敗。Effect 會在每個 render 後都被重新執行,這甚至比沒有寫 dependencies 的效能還要糟糕 — 畢竟比較值的動作還是需要花費效能的。
會有這樣的結果是因為 fetchData
這個變數其實在每次 render 都會是不同的。我們將這個函式宣告在 component 中,因此每次 render 時它都會重新被產生,並依賴該次 render 的資料。相信你還記得前面章節提到的這個觀念 — 每次 render 都有自己的 props、 state 以及 event handlers。
因此,當我們的 effect 依賴了一個函式卻又沒有做特別的處理時,就有可能會導致效能的最佳化失敗。這個問題的解決方法並不是欺騙 dependencies,我們會介紹幾種應對的方法:
承接上面的範例,如果你只在一個 effect 裡使用某個函式,其實可以把該函式直接放進那個 effect 裡面定義:
function SearchResults() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('react');
useEffect(
() => {
// 把函式定義移到 useEffect 中
// 這個函式就只有在 effect 執行時才會重新產生
async function fetchData() {
const result = await axios(
`https://foo.com/api/search?query=${query}`,
);
setData(result.data);
}
fetchData();
},
[query] // 這裡的 dependencies 是誠實的
);
// ...
}
將函式搬進 effect 中能夠讓這個函式避免隨著 re-render 在不必要的時候重新產生。此時因為函式是寫在 effect 裡的,因此它就不會是一個依賴,而是改成直接依賴的真正要連動的資料 query
。
需要稍微提醒一下的是, useEffect
的第一個參數函式不可以是一個 async function,因此在這個範例中你必須先在 effect function 內部宣告一個 async function,才能在這個 async function 內部使用 await
語法。當然,你也可以乾脆改成以 promise.then
來寫。
有時候我們可能會不想要把函式直接定義在 effect 裡,像是在同元件的不同 effect 中都呼叫這個函式時,我們不想要複製貼上這段邏輯:
function SearchResults() {
async function fetchData(query) {
const result = await axios(
`https://foo.com/api/search?query=${query}`,
);
}
useEffect(
() => {
fetchData('react').then(result => { /* 用資料進行某些操作 */ });
},
// deps 誠實,但是 fetchData 函式每次 render 時都會重新產生,
// 因此這個 effect deps 的效能最佳化完全無效
[fetchData]
);
useEffect(
() => {
fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
},
// deps 誠實,但是 fetchData 函式每次 render 時都會重新產生,
// 因此這個 effect deps 的效能最佳化完全無效
[fetchData]
);
// ...
}
同樣的,在這個範例中當我們將 fetchData
填進 dependencies 後,雖然我們對他誠實了,但這個效能最佳化是永遠失敗的!
如果一個函式或是部分的流程沒有使用到任何 component 內的資料或依賴的話,你其實可以把它們移到 component 的外面:
async function fetchData(query) {
const result = await axios(
`https://foo.com/api/search?query=${query}`,
);
return result;
}
function SearchResults() {
useEffect(
() => {
fetchData('react').then(result => { /* 用資料進行某些操作 */ });
},
// deps 誠實,因為 fetchData 是一個在 component 外部永遠不會改變的函式
[]
);
useEffect(
() => {
fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
},
// deps 誠實,因為 fetchData 是一個在 component 外部永遠不會改變的函式
[]
);
// ...
}
此時我們就可以不用將這個函式列在 effect 的 dependencies 裡,因為它並不是定義在 component 當中,因此並不會隨著每次 render 而重新產生,也就不會被資料流所連動影響 — 它不可能直接以 closure 的方式依賴 props 或 state。
useCallback
包起來你應該優先嘗試上面介紹的將函式抽到 component 外的做法。不過有時候你的函式有可能會依賴許多 component 中的資料,此時如果將函式抽到 component 外部的話反而會需要傳遞過多的參數,讓資料流的可讀性下降。
因此,React 其實有內建的配套措施可以幫助我們解決這個問題 — useCallback
。
useCallback
就像是資料流中連動反應的另一層檢查。當 useCallback
的 dependencies 中的依賴與前一次 render 時都相同時,它會返回前一次 render 版本的函式。useCallback
本身的這個行為對於「避免每次 render 重複產生函式」其實並沒有任何幫助,因為 component 在每次 render 仍然每次都會產生一個新的 inline function 之後才傳給 useCallback。然而,當這個效果搭配 useEffect
使用時則可以對 effect 的 dependencies 效能最佳化大有幫助!
function SearchResults(props) {
const fetchData = useCallback(
async (query) => {
const result = await axios(
`https://foo.com/api/search?query=${query}&rowsPerPage=${props.rows}`,
);
return result;
},
[props.rows] // callback deps 誠實
);
useEffect(
() => {
fetchData('react').then(result => { /* 用資料進行某些操作 */ });
},
// effect deps 是誠實的,
// 且只有當 props.rows 不同時,fetchData 才會被重新產生,連帶的此時 effect 才會再次被執行。
// 而如果 props.rows 沒有改變時,useCallback 就會回傳與前一次 render 相同的函式,
// 則連帶的這個 effect 就會被忽略。
// 因此這裡的 effect deps 效能最佳化可以正常發揮效果
[fetchData]
);
// ...
在上面這個範例中,我們將 fetchData
函式直接定義在 component 中,函式裡依賴了 props.rows
資料,且會在 effect 中被呼叫。如果我們沒有使用 useCallback
將 fetchData
包起來的話,effect 就會因為 fetchData
在每次 render 時都不同而永遠最佳化失敗。而如果我們 fetchData
以 useCallback
包起來的話,這個函式就能夠參與到 component 的「dependencies chain」當中了。
只有當 props.rows
不同時,fetchData
才會被重新產生,連帶的此時 effect 才會再次被執行。而如果 props.rows
沒有改變時,useCallback
就會回傳與前一次 render 相同的函式,則連帶的這個 effect 就會在該次 render 時被略過。因此這裡的 effect dependencies 效能最佳化可以正常發揮效果,它就像是一個資料流的連鎖反應一樣。
有了 useCallback
的輔助之下,函式完全可以參與資料流。如果函式所依賴的資料有改變的話,函式才會跟著改變,而如果依賴不變的話,它會保持與前一次 render 時是相同的函式。而 useMemo
也是類似的應用概念。可以稍微補充的是,我們並不需要將所有 component 內的函式都以 useCallback
包起來,而是當這個函式會被使用在 effect 中,或是作為 React.memo
的比較的 props 傳遞時,再使用 useCallback
就好。
以上的範例與解析為了我們更明確地展現出了一個概念:
函式在 function component 與 hooks 中是屬於資料流的一部份。
useCallback
& useMemo
可以讓讓由原始資料產生出來的延伸資料也能夠參與資料流,並配合 dependencies chain 在維持 useEffect
的同步可靠性的同時,也兼顧 effect 的效能最佳化。
exhaustive-deps
linter rule在目前為止的篇幅中,我們解析了為什麼應該永遠對 hooks 的 dependencies 保持誠實,以及一些如何安全的減少或調整 effect dependencies 的方法。然而,即使我們有意對 dependencies 保持誠實,但實際開發時總還是會遇到有所遺漏的時候。不過慶幸的是,React 官方有提供專門幫助你偵測甚至自動修正 hooks dependencies 的 linter rule 工具,能夠幫助我們在開發階段就透過靜態分析提前找出問題,非常推薦所有 React 開發者都應該使用這個輔助工具。
這個 linter rule 已經內建在 Create React App 當中,因此如果你是透過其建立專案的話,應該就能在支援的編輯器中使用。當然,你也可以另外自行安裝。
在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》
目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:
天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695
博客來(平裝版):
https://www.books.com.tw/products/0010982322
momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845
Zet 大大您好,
想問您第二段的程式,那如果我把 dep array 的 fetchData 直接換成 query 是不是一樣也能成功執行呢?還是這樣寫會有什麼其他 effect 在裡面呢?
function SearchResults() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('react');
async function fetchData() {
const result = await axios(
`https://foo.com/api/search?query=${query}`,
);
setData(result.data);
}
useEffect(
() => {
fetchData();
},
[fetchData] // 這個 deps 的效能最佳會永遠都會失敗,因為 fetchData 每次 render 時都不一樣
);
// ...
}
你好,在這個範例中將 useEffect
的 dependencies 改成填 query
的話,原則上最佳化會有效果,但是這種寫法會有一些負面影響:
fetchData
事後發生改寫,而使用到更多其他依賴資料時,若這個 useEffect
的 dependencies 如果沒有跟著加上對應的新依賴的話,則 effect 的效能最佳化就會有問題,而這通常很容易被開發者給遺漏fetchData
而不是 query
。而一旦你將 linter 關掉,就有可能會遇到上面第一點的問題因此,原則上非常不建議你嘗試這樣寫。直接保持 dependencies 的誠實,讓函式參與資料流,並且使用 linter rule 作為輔助檢查,會是更安全直覺且不容易發生疏漏的方式。
非常謝謝 zet 大大的回覆,
關於您回覆的第一點,以及配合您最後一段的範例 Hooks exhaustive-deps linter rule
,也是將 query 放在 dep array。我看當中的差異只有 fetchData
是否在 useEffect 內定義,那這樣的做法豈不是也會在之後開發時遺漏新的依賴呢?